<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, minimum-scale=1.0, maximum-scale=1.0">
<title>Wireshark: IP Location Map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.4.0/dist/leaflet.css"
    integrity="sha512-puBpdR0798OZvTTbP4A8Ix/l+A4dHDD0DGqYW6RQ+9jxkRFclaxxQb/SJAWZfWAkuyeQUytO7+7N4QKrDh+drA=="
    crossorigin="">
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css"
    integrity="sha512-BBToHPBStgMiw0lD4AtkRIZmdndhB6aQbXpX7omcrXeG2PauGBl2lzq2xUZTxaLxYz5IDHlmneCZ1IJ+P3kYtQ=="
    crossorigin="">
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css"
    integrity="sha512-RLEjtaFGdC4iQMJDbMzim/dOvAu+8Qp9sw7QE4wIMYcg2goVoivzwgSZq9CsIxp4xKAZPKh5J2f2lOko2Ze6FQ=="
    crossorigin="">
<!--
<link rel="stylesheet" href="https://unpkg.com/leaflet-measure@3.1.0/dist/leaflet-measure.css"
    integrity="sha512-wgiKVjb46JxgnGNL6xagIy2+vpqLQmmHH7fWD/BnPzouddSmbRTf6xatWIRbH2Rgr2F+tLtCZKbxnhm5Xz0BcA=="
    crossorigin="">
-->
<style>
html, body {
    margin: 0;
    padding: 0;
    height: 100%;
}
#map {
    height: 100%;
}
.file-picker-enabled #map, #file-picker-container {
    display: none;
}
.file-picker-enabled #file-picker-container {
    display: block;
    margin: 2em;
}
.range-control {
    padding: 3px 5px;
    color: #333;
    background: #fff;
    opacity: .5;
}
.range-control:hover { opacity: 1; }
.range-control-label { padding-right: 3px; }
.range-control-input { padding: 0; width: 130px; }
.range-control-input, .range-control-label { vertical-align: middle; }
</style>
<script src="https://unpkg.com/leaflet@1.4.0/dist/leaflet.js"
    integrity="sha512-QVftwZFqvtRNi0ZyCtsznlKSWOStnDORoefr1enyq5mVL4tmKB3S/EnC3rRJcxCPavG10IcrVGSmPh6Qw5lwrg=="
    crossorigin=""></script>
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"
    integrity="sha512-MQlyPV+ol2lp4KodaU/Xmrn+txc1TP15pOBF/2Sfre7MRsA/pB4Vy58bEqe9u7a7DczMLtU5wT8n7OblJepKbg=="
    crossorigin=""></script>
<!--
<script src="https://unpkg.com/leaflet-measure@3.1.0/dist/leaflet-measure.js"
    integrity="sha512-ovh6EqS7MUI3QjLWBM7CY8Gu8cSM5x6vQofUMwKGbHVDPSAS2lmNv6Wq5es5WCz1muyojQxcc8rA3CvVjD2Z+A=="
    crossorigin=""></script>
-->
<script>
var map;

function sortIpKey(v) {
    if (/\./.test(v)) {
        // Assume IPv4. Convert 192.0.2.34 -> 192.000.002.034 for alpha sort.
        return v.replace(/\b\d\b/g, '00$&').replace(/\b\d{2}\b/g, '0$&');
    } else {
        // Assume IPv6. We won't handle :: correctly. Hope for the best.
        return v;
    }
}

function escapeHtml(text) {
    if (!text) return '';
    return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function sanitizeHtml(text) {
    // Handle legacy data containing <div class="geoip_property">...</div>
    // (since Wireshark 2.0) or <br/> (before v1.99.0-rc1-1781-g7e63805708).
    text = text
        .replace(/<div[^>]*>/g, '')
        .replace(/<\/div>|<br\/>/g, '\n')
        .replace(/&#39;/g, "'");
    return escapeHtml(text).replace(/\n/g, '<br>');
}

var RangeControl = L.Control.extend({
    options: {
        // @option label: String = 'Speed:'
        // The HTML text to be displayed next to the slider.
        label: '',
        title: '',

        min: 0,
        max: 100,
        value: 0,

        // @option onChange: Function = *
        // A `Function` that is called on slider value changes.
        // Called with two arguments, the new and previous range value.
    },
    onAdd: function(map) {
        var className = 'range-control';
        var container = L.DomUtil.create('div', className + ' leaflet-bar');
        L.DomEvent.disableClickPropagation(container);
        var label = L.DomUtil.create('label', className + '-label', container);
        var labelText = L.DomUtil.create('span', className + '-label', label);
        labelText.title = this.options.title;
        labelText.innerHTML = this.options.label;
        var input = L.DomUtil.create('input', className + '-input', label);
        this._input = input;
        input.type = 'range';
        input.min = this.options.min;
        input.max = this.options.max;
        this._lastValue = input.valueAsNumber = this.options.value;
        L.DomEvent.on(input, 'change', this._onInputChange, this);
        return container;
    },
    _onInputChange: function(ev) {
        var value = this._input.valueAsNumber;
        if (value !== this._lastValue) {
            if (this.options.onChange) {
                this.options.onChange(value, this._lastValue);
            }
            this._lastValue = value;
        }
    }
});

var rangeControl = function(options) {
    return new RangeControl(options);
};

function loadGeoJSON(obj) {
    'use strict';
    if (map) map.remove();
    map = L.map('map');
    var tileServer = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
    L.tileLayer(tileServer, {
        minZoom: 2,
        maxZoom: 16,
        subdomains: 'abcd',
        attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
    }).addTo(map);
    L.control.scale().addTo(map);

    // Measurement tool, useful for investigating accuracy-related issues.
    if (L.control.measure) {
        L.control.measure({
            primaryLengthUnit: 'kilometers',
            secondaryLengthUnit: 'miles'
        }).addTo(map);
    }

    var geoJson = L.geoJSON(obj, {
        pointToLayer: function(feature, latlng) {
            // MaxMind databases use km for accuracy, but they always use
            // 50, 100, 200 or 1000. That is too course, so ignore it and use a
            // fixed 1km radius.
            // See https://bugs.wireshark.org/bugzilla/show_bug.cgi?id=14693#c12
            return L.circle(latlng, {radius: 1e3});
        },
        onEachFeature: function(feature, layer) {
            var props = feature.properties;
            var title, lines = [];
            if (props.title && props.description) {
                title = escapeHtml(props.title);
                lines.push(sanitizeHtml(props.description));
            } else {
                title = escapeHtml(props.ip);
                if (props.autonomous_system_number) {
                    var line = 'AS: ' + props.autonomous_system_number;
                    line += ' (' + props.autonomous_system_organization + ')';
                    lines.push(escapeHtml(line));
                }
                if (props.city) {
                    lines.push(escapeHtml('City: ' + props.city));
                }
                if (props.country) {
                    lines.push(escapeHtml('Country: ' + props.country));
                }
                if ('packets' in props) {
                    lines.push(escapeHtml('Packets: ' + props.packets));
                }
                if ('bytes' in props) {
                    lines.push(escapeHtml('Bytes: ' + props.bytes));
                }
            }
            if (title) {
                layer.bindTooltip(title, {
                    offset: [10, 0],
                    direction: 'right',
                    sticky: true
                });
            }
            if (title && lines.length) {
                layer.bindPopup('<b>' + title + '</b><br>' + lines.join('<br>'));
            }
        }
    });

    map.on('zoomend', function() {
        // Ensure that the circles are clearly visible even when zoomed out.
        // Larger values will increase the size of the circle.
        var visibleZoomLevel = 9;
        var radius = 1e3;
        if (map.getZoom() < visibleZoomLevel) {
            // Enlarge radius to ensure it is easy to select.
            radius *= map.getZoomScale(visibleZoomLevel, map.getZoom());
        }
        geoJson.eachLayer(function(layer) {
            layer.setRadius(radius);
        });
    });

    // Cluster nearby/overlapping nodes by default.
    var clusterGroup = L.markerClusterGroup({
        zoomToBoundsOnClick: false,
        spiderfyOnMaxZoom: false,
        maxClusterRadius: 10
    });
    clusterGroup.addTo(map).addLayer(geoJson);
    map.fitWorld().fitBounds(clusterGroup.getBounds());

    // Summarize nodes within the cluster.
    clusterGroup.on('clustermouseover', function(ev) {
        // More addresses will be stripped.
        var cutoff = 30;
        var cluster = ev.propagatedFrom;
        var addresses = cluster.getAllChildMarkers().map(function(marker) {
            return marker.getTooltip().getContent();
        });
        addresses.sort(function(a, b) {
            a = sortIpKey(a);
            b = sortIpKey(b);
            return a === b ? 0 : (a < b ? -1 : 1);
        });
        var deleted = addresses.splice(cutoff).length;
        var title = addresses.join('<br>');
        if (deleted) {
            title += '<br>(and ' + deleted + ' more)';
        }
        cluster.bindTooltip(title, {
            offset: [10, 0],
            direction: 'right',
            sticky: true,
            opacity: 0.8
        }).openTooltip();
    }).on('clustermouseout', function(ev) {
        ev.propagatedFrom.unbindTooltip();
    }).on('clusterclick', function(ev) {
        ev.propagatedFrom.spiderfy();
    });

    // Provide an option to disable clustering
    rangeControl({
        label: 'Cluster radius:',
        title: 'Control merging of nearby nodes. Set to the minimum to disable merges.',
        min: 0,
        max: 100,
        value: clusterGroup.options.maxClusterRadius,
        onChange: function(value, oldValue) {
            // Apply new radius: remove map, clear markers and finally add new.
            clusterGroup.options.maxClusterRadius = value;
            clusterGroup.remove().clearLayers().addTo(map);
            // Value 0: clustering is disabled, the map is directly used.
            geoJson.remove().addTo(value === 0 ? map : clusterGroup);
        }
    }).addTo(map);
}

function showError(msg) {
    document.getElementById('error-message').textContent = msg;
    document.body.classList.add('file-picker-enabled');
}

function loadData(data) {
    'use strict';
    var html_match, what, error;
    var reOldHtml = /^ *var endpoints = (\{[\s\S]+? *\});$/m;
    // Complicated regex to support html-minifier.
    var reNewHtml = /<script[^>]+id="?ipmap-data"?(?: [^>]*)?>\s*(\{[\S\s]+?\})\s*<\/script>/;
    if ((html_match = reNewHtml.exec(data))) {
        // Match new ipmap.html file.
        what = 'new ipmap.html';
        data = html_match[1];
    } else if ((html_match = reOldHtml.exec(data))) {
        // Match old ipmap.html file
        what = 'old ipmap.html';
        var text = html_match[1].replace(/'/g, '"');
        text = text.replace(/ class="geoip_property"/g, '');
        data = text.replace(/\/\/ Start endpoint list.*/, '');
    } else if (/^\s*\{[\s\S]+\}\s*$/.test(data)) {
        // Assume GeoJSON (.json) file.
        what = 'GeoJSON file';
    } else {
        what = 'unknown file';
        error = 'Unrecognized file contents';
    }
    if (!error) {
        try {
            loadGeoJSON(JSON.parse(data));
            return true;
        } catch (e) {
            error = e;
        }
    }
    var msg = 'Failed to load map data from ' + what + ': ' + error;
    msg += '; data was: ' + data.substring(0, 120);
    if (data.length > 100) msg += '... (' + data.length + ' bytes)';
    showError(msg);
}

(function() {
    'use strict';
    function loadFromUrl(url) {
        var xhr = new XMLHttpRequest();
        xhr.open('GET', url, true);
        xhr.onload = function() {
            if (xhr.status !== 200) {
                showError('Failed to retrieve ' + url + ': ' + xhr.status + ' ' + xhr.statusText);
                return;
            }
            loadData(xhr.responseText);
        };
        xhr.onerror = function() {
            showError('Failed to retrieve ' + url + ': ' + xhr.status + ' ' + xhr.statusText);
        };
        xhr.send(null);
    }

    addEventListener('load', function() {
        // Note: FileReader and classList do not work with IE9 or older.
        var fileSelector = document.getElementById('file-picker');
        fileSelector.addEventListener('change', function() {
            if (!fileSelector.files.length) {
                return;
            }
            document.body.classList.remove('file-picker-enabled');
            var reader = new FileReader();
            reader.onload = function() {
                if (!loadData(reader.result)) {
                    document.body.classList.add('file-picker-enabled');
                }
            };
            reader.onerror = function() {
                showError('Failed to read file.');
            };
            reader.readAsText(fileSelector.files[0]);
        });

        // Force file picker when the "file" URL is given.
        var url = location.search.match(/[?&]url=([^&]*)/);
        if (url) {
            url = decodeURIComponent(url[1]);
            if (url) {
                loadFromUrl(url);
            } else {
                showError('');
            }
            return;
        }

        var data = document.getElementById('ipmap-data');
        if (data) {
            loadData(data.textContent);
        } else {
            showError('');
        }
    });
}());
</script>
<div id="file-picker-container">
<label>Select an ipmap.html or GeoJSON .json file as created by Wireshark.<br>
<input type="file" id="file-picker" accept=".json,.html"></label>
<p id="error-message"></p>
</div>
<div id="map"></div>
<!--
    Wireshark will append a script tag (id="ipmap-data" type="application/json")
    below, containing a GeoJSON object. If missing, then a file picker will be
    displayed which can be useful during development.
-->
